Skip to content

feat(font): replace generated font catalog with Vite import-rewriting transform#262

Open
yunus25jmi1 wants to merge 4 commits intocloudflare:mainfrom
yunus25jmi1:feat/font-vite-transform
Open

feat(font): replace generated font catalog with Vite import-rewriting transform#262
yunus25jmi1 wants to merge 4 commits intocloudflare:mainfrom
yunus25jmi1:feat/font-vite-transform

Conversation

@yunus25jmi1
Copy link
Contributor

Summary

Closes #200

Instead of a 1928-line generated font catalog (font-google.generated.ts) that re-exports every Google Font as a createFontLoader call, the vinext:google-fonts Vite plugin now rewrites next/font/google import statements at compile time.

Before

// font-google.generated.ts (1928 lines, ~70KB)
export const Inter: FontLoader = createFontLoader('Inter');
export const Roboto: FontLoader = createFontLoader('Roboto');
// ... 1926 more lines

After

The plugin rewrites imports at compile time across all Vite environments (RSC, SSR, client):

// User writes:
import { Inter, Roboto_Mono } from 'next/font/google'

// Plugin transforms to:
import { createFontLoader as __vinext_clf } from 'next/font/google';
const Inter = __vinext_clf('Inter');
const Roboto_Mono = __vinext_clf('Roboto Mono');

Self-hosted font fetching during production builds continues to work as before.

Changes

  • Delete packages/vinext/src/shims/font-google.generated.ts (1928 lines / ~70 KB)
  • Update font-google.ts to export createFontLoader instead of re-exporting the catalog
  • Add createFontLoader export to next-shims-font-google.generated.d.ts for IDE autocomplete
  • Rewrite vinext:google-fonts Vite plugin to perform import-time transforms in dev + build
  • Fix Proxy in font-google-base.ts to handle underscore-style names (Roboto_Mono'Roboto Mono') for users who skip the Vite plugin
  • Update generate-google-fonts.js to only generate .d.ts (no longer generates .ts catalog)
  • Update tests to use createFontLoader API instead of named exports

Testing

  • All 54 font-google.test.ts tests pass
  • All 7 font/google tests in shims.test.ts pass
  • All 9 entry-templates.test.ts tests pass

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@262

commit: 3fa21b8

@yunus25jmi1
Copy link
Contributor Author

@southpolesteve @threepointone @elithrar Kindly review the changes. The workflow is completely green. All checks are passed with 12 successful checks & 3 skipped.

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(font): replace generated font catalog with Vite import-rewriting transform

The core idea is great — replacing a 1928-line generated catalog with a compile-time import rewrite is a clear win for maintenance and bundle size. The import-rewriting logic is well-structured and handles edge cases like type imports, as aliases, and utility re-exports.

However, there are several issues that need to be addressed before merging.

Critical Issues

  1. Build-mode self-hosting runs regex on the original code, but MagicString has already rewritten the import. The fontCallRe regex runs on the original code string (line 2978), but s.overwrite() has already replaced the import region. The indices from the original regex may now overlap with or conflict with the overwritten region. If a font constructor call like Inter({ weight: '400' }) appears on the same line as the import, MagicString will throw or silently produce corrupted output. The self-hosting section should either regex-match on the already-transformed code or ensure the regions don't overlap.

  2. Only the first import { ... } from 'next/font/google' statement is handled. The regex uses .exec() without the g flag, so a file with two separate import statements from next/font/google (which is valid JS) will silently ignore the second one. This is an existing limitation that the old code also had, but given that this PR is rewriting the import handling, it's worth fixing now.

  3. The vinext:nitro-compat plugin is unrelated to the font transform. This is a completely separate feature/fix that should be in its own PR. It also contains console.log debug statements that should not ship to production.

Minor Issues

  1. Font family derivation inconsistency between import rewrite and build-mode self-hosting. The import rewrite uses f.imported.replace(/_/g, " ") (underscore-to-space only), but the build-mode self-hosting uses fontSpec.imported.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2") (underscore-to-space + PascalCase-to-space). Since Google Fonts uses underscores in their Next.js export names (not PascalCase), the PascalCase transform in the build path is dead code that could introduce bugs if someone used PascalCase names. These should be consistent.

  2. Missing test for import { Inter as MyInter } from 'next/font/google' — the alias handling code is present but untested.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 6, 2026

Review submitted. Here's a summary of the findings:

Critical Issues (3)

  1. MagicString index corruption in build mode — The self-hosting regex runs on the original code string after s.overwrite() has already rewritten the import statement. If a font constructor call appears on the same line as the import, the character indices will be wrong, causing MagicString to throw or produce corrupted output.

  2. Only first import statement handled — The import regex uses .exec() without the g flag, so files with two separate import { ... } from 'next/font/google' statements will silently ignore the second one.

  3. Unrelated vinext:nitro-compat plugin — A completely separate Nitro compatibility fix was bundled into this font transform PR, including debug console.log statements that shouldn't ship to production. This should be a separate PR.

Minor Issues (3)

  1. Inconsistent font family derivation between the import rewrite path (underscore-to-space only) and the build-mode self-hosting path (underscore-to-space + PascalCase-to-space).

  2. id.includes("font-google") guard could false-positive on user files named font-google-helpers.ts.

  3. Missing test coverage for the import { Inter as MyFont } alias pattern and for fonts with digits/lowercase after underscores (e.g., Baloo_2).

github run

… transform

Closes cloudflare#200

Instead of a 1928-line generated font catalog (font-google.generated.ts) that
re-exports every Google Font as a createFontLoader call, the vinext:google-fonts
Vite plugin now rewrites next/font/google import statements at compile time.

Address review feedback:
- Fix MagicString index corruption: add overlap guard for self-hosting
  regex so font constructor calls that share a region with imported
  statements are safely skipped
- Handle multiple import statements: use matchAll with gm flags to
  process all import { ... } from 'next/font/google' in a single file
- Remove unrelated vinext:nitro-compat plugin (belongs in separate PR)
- Fix font family derivation consistency: remove dead PascalCase-to-space
  transform in build-mode path to match dev-mode (underscore-to-space only)
- Fix font-google guard: use shimsDir path check instead of id.includes()
  to avoid false positives on user files like font-google-helpers.ts
- Fix fontCallRe regex: allow digits and lowercase after underscores to
  match fonts like Baloo_2 and M_PLUS_1p
- Add utilityExports sync comment referencing font-google.ts barrel
- Add tests: aliased imports, multiple import statements, digit-underscore
  font names
@yunus25jmi1 yunus25jmi1 force-pushed the feat/font-vite-transform branch from aae8f42 to 8c1d17f Compare March 6, 2026 16:45
@yunus25jmi1
Copy link
Contributor Author

@southpolesteve @james-elicx The fixes is pushed. Kindly review the changes.

@yunus25jmi1 yunus25jmi1 requested a review from james-elicx March 6, 2026 17:27
@james-elicx
Copy link
Collaborator

/bigbonk review

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 6, 2026

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(font): replace generated font catalog with Vite import-rewriting transform

The core approach is sound — replacing a 1928-line generated catalog with a compile-time import rewrite is a clear maintenance and bundle-size win. The author has addressed the critical issues from the previous review (nitro-compat plugin removed, multiple import statements handled via gm flags, MagicString overlap detection added, alias test added, Baloo_2 test added). The implementation is well-structured with good separation between the import rewriting phase and the build-only self-hosting phase.

The remaining items are minor and don't block merge, but would be good to address.

// indices, so overlapping overwrites would throw.
const callStart = callMatch.index;
const callEnd = callStart + fullCallMatch.length;
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: variable shadowing — The destructured [s, e] shadows the outer const s = new MagicString(code) on line 2947. This doesn't cause a functional bug (the arrow function only uses s for index comparison, not as a MagicString), but it's a readability hazard — a future reader might think s here refers to the MagicString.

Suggested change
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) continue;

// The ^ anchor with 'm' flag ensures we only match real import statements
// at the start of a line, not occurrences inside comments or strings.
// Uses the 'gm' flags to handle multiple import statements in one file.
const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ^ anchor with m flag can match inside template literals. A multiline template string like:

const x = `
import { Inter } from 'next/font/google'
`;

would be matched by this regex because the m flag makes ^ match at the start of any line, even inside template literals. In practice this is extremely unlikely to matter (who puts a next/font/google import inside a template string?), but worth noting if you want maximum robustness. The typical fix is to use an AST-based approach, but for a Vite transform this is a reasonable tradeoff.

// indices, so overlapping overwrites would throw.
const callStart = callMatch.index;
const callEnd = callStart + fullCallMatch.length;
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note: self-hosting silently skipped for same-line import+call. When the import and constructor call are on the same line:

import { Inter } from 'next/font/google'; const inter = Inter({ weight: ['400'] });

The import rewriting works correctly (Inter gets replaced with createFontLoader call), but the self-hosting CSS injection is skipped because the call overlaps with the overwritten import region. The font will still work in production but will load from CDN instead of being self-hosted.

This is a fine tradeoff since the single-line pattern is rare, but a comment documenting this intentional limitation would help future readers understand why the overlap check exists and what the user-facing consequence is.

// Utility exports that are NOT font names — keep as regular imports.
// IMPORTANT: keep this set in sync with the non-default exports from
// packages/vinext/src/shims/font-google.ts (and its re-export barrel).
const utilityExports = new Set([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on the IMPORTANT: keep this set in sync comment. For even more robustness, consider deriving this set programmatically from the actual exports of font-google.ts (e.g., in a test) rather than relying on a manual sync. A test like:

it('utilityExports matches actual non-font exports', async () => {
  const mod = await import('../packages/vinext/src/shims/font-google.js');
  const actual = Object.keys(mod).filter(k => k !== 'default');
  // Verify they match the hardcoded set in the plugin
});

would catch drift automatically.

const fontCallRe = /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g;
// Skip our own shim files — they contain 'next/font/google' in comments
// and re-export statements that should not be rewritten.
if (id.startsWith(path.resolve(__dirname, "shims") + path.sep)) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using path.resolve(__dirname, 'shims') is more robust than the previous id.includes('font-google') guard that was flagged by the earlier review. Good fix.

// Convert export-style names to proper font family names:
// - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono"
// - PascalCase to spaces: "RobotoMono" -> "Roboto Mono"
const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — the Proxy now handles both underscore-style names (Roboto_Mono) and PascalCase names (RobotoMono). This keeps the Proxy as a correct fallback for cases where the Vite plugin doesn't run (e.g., direct runtime access via mod.default.SomeFontName).

plugin._fontCache.clear();
});

it("rewrites aliased font imports (import { Inter as MyFont })", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this alias test was called out as missing in the previous review and has been added. Coverage for the import { X as Y } pattern is important since the rewriting code has separate imported vs local handling.

expect(result.code).toContain("merged into first");
});

it("handles font names with digits after underscore (e.g. Baloo_2)", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — test for fonts with digits after underscore (Baloo_2) was also called out as missing and has been added. The updated fontCallRe regex on line 2998 of index.ts now matches [A-Za-z0-9] after underscore, which handles this correctly.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 6, 2026

Review submitted successfully. Here's a summary of my findings:

Review Summary for PR #262

Overall assessment: The PR is in good shape. The core approach of replacing a 1928-line generated font catalog with a compile-time Vite import rewrite is a clear win. The author addressed all critical issues from the previous review.

Key findings:

  1. Variable shadowing (minor) — On line 3010, the destructured [s, e] in the overlap-check arrow function shadows the outer MagicString variable s. Not a functional bug, but a readability hazard. Suggested renaming to [start, end].

  2. Regex edge case (informational) — The ^ anchor with m flag could theoretically match import statements inside template literals. Extremely unlikely in practice.

  3. Silent self-hosting skip (informational) — When import and constructor call are on the same line, self-hosting CSS injection is silently skipped (font falls back to CDN). This is a reasonable tradeoff but should be documented with a comment.

  4. Utility exports sync (suggestion) — The hardcoded utilityExports set should ideally be validated by a test to catch drift.

  5. Positive notes — Good fix for the shim path guard (using path.resolve(__dirname, "shims") instead of id.includes("font-google")), good test coverage for aliases and digit-containing font names, and clean separation between import rewriting and self-hosting phases.

github run

@yunus25jmi1
Copy link
Contributor Author

@james-elicx The PR is in good shape. Merge this PR.

@james-elicx
Copy link
Collaborator

The PR is in good shape. Merge this PR.

The general flow we're trying to follow at the moment is continually iterating until Bonk approves

@yunus25jmi1
Copy link
Contributor Author

@copilot ask-bonk to review the pr and merge the pr.

@james-elicx
Copy link
Collaborator

/bigbonk please re-review this pr

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(font): replace generated font catalog with Vite import-rewriting transform

The core design is a solid improvement — replacing ~1,900 lines of generated catalog with a compile-time import rewrite is a clear maintenance and bundle-size win. The previous review rounds have addressed the most critical issues (multiple import handling, MagicString overlap detection, nitro-compat removal, alias and digit-font tests). The implementation is well-structured overall.

I found one functional issue that should be fixed, and a few minor items worth addressing.

Issues

  1. Variable shadowing in overlap check (minor bug risk) — [s, e] shadows the outer MagicString s. This was flagged in the previous bonk review but the suggestion hasn't been applied yet.

  2. Self-hosting silently degrades with no documentation — When an import and constructor call share the same line, self-hosting is silently skipped and the font falls back to CDN. This is a reasonable tradeoff but should have a code comment explaining the user-facing consequence.

  3. utilityExports set is a maintenance hazard — The hardcoded set must stay in sync with font-google.ts exports. A test that validates this would prevent silent drift.

  4. Missing type re-export in transformed output for multi-import merging — When type specifiers come from different import statements (e.g., import { type FontConfig } from 'next/font/google' on one line and import { Inter } from 'next/font/google' on another), the type specifiers from the second import are merged into the first import's replacement, but the second import is replaced with a comment. This works correctly. However, if a user only has type imports from next/font/google (no font names), fontImports.length === 0 causes the transform to bail out and return null, which is correct since there's nothing to transform. Good.

Positive notes

  • The path.resolve(__dirname, 'shims') guard is robust — good fix from the previous review.
  • /*#__PURE__*/ annotations on createFontLoader calls enable tree-shaking.
  • Test coverage for aliases, digit fonts, and multiple imports is thorough.
  • The Proxy fallback in font-google-base.ts now handles both underscore and PascalCase names, which is correct.

// indices, so overlapping overwrites would throw.
const callStart = callMatch.index;
const callEnd = callStart + fullCallMatch.length;
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable shadowing: [s, e] shadows the outer const s = new MagicString(code) from line 2947. This was flagged in the previous bonk review but hasn't been fixed. While it doesn't cause a functional bug today (the s in the arrow function is only used for index comparison), it's a readability hazard that could cause a real bug if someone later adds MagicString operations inside this callback.

Suggested change
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
if (overwrittenRanges.some(([start, end]) => callStart < end && callEnd > start)) continue;

Comment on lines +3005 to +3010
// Skip calls that overlap with an already-overwritten import region
// (e.g. import and call on the same line). MagicString uses original
// indices, so overlapping overwrites would throw.
const callStart = callMatch.index;
const callEnd = callStart + fullCallMatch.length;
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent self-hosting skip deserves a comment: When the import and constructor call are on the same line (e.g., import { Inter } from 'next/font/google'; const inter = Inter({...});), the overlap check correctly prevents MagicString corruption by skipping the self-hosting injection. But the user-facing consequence — the font loads from CDN instead of being self-hosted — is non-obvious.

Consider adding a brief comment documenting this limitation:

Suggested change
// Skip calls that overlap with an already-overwritten import region
// (e.g. import and call on the same line). MagicString uses original
// indices, so overlapping overwrites would throw.
const callStart = callMatch.index;
const callEnd = callStart + fullCallMatch.length;
if (overwrittenRanges.some(([s, e]) => callStart < e && callEnd > s)) continue;
// Skip calls that overlap with an already-overwritten import region
// (e.g. import and call on the same line). MagicString uses original
// indices, so overlapping overwrites would throw.
// NOTE: Skipped fonts will still work but load from CDN instead of being self-hosted.
const callStart = callMatch.index;
const callEnd = callStart + fullCallMatch.length;

Comment on lines +2916 to +2922
// Utility exports that are NOT font names — keep as regular imports.
// IMPORTANT: keep this set in sync with the non-default exports from
// packages/vinext/src/shims/font-google.ts (and its re-export barrel).
const utilityExports = new Set([
"buildGoogleFontsUrl", "getSSRFontLinks", "getSSRFontStyles",
"getSSRFontPreloads", "createFontLoader",
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync hazard: This hardcoded set must stay in sync with the actual exports from font-google.ts. The comment is helpful, but a test would be more reliable. Consider adding something like:

it('utilityExports matches actual non-font exports from font-google.ts', async () => {
  const mod = await import('../packages/vinext/src/shims/font-google.js');
  const expectedUtilities = Object.keys(mod).filter(k => k !== 'default' && typeof mod[k] === 'function');
  // compare against the hardcoded set in the plugin
});

This would catch drift automatically rather than relying on the comment.

// The ^ anchor with 'm' flag ensures we only match real import statements
// at the start of a line, not occurrences inside comments or strings.
// Uses the 'gm' flags to handle multiple import statements in one file.
const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The ^ anchor with m flag matches at the start of any line, including inside template literals. For example:

const x = `
import { Inter } from 'next/font/google'
`;

would be matched. In practice this is extremely unlikely (who puts a next/font/google import inside a template string?), so this is an acceptable tradeoff vs. a full AST parse. Just noting for awareness.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted successfully. Here's a summary of my findings:

Review Summary for PR #262

Overall: The approach is a clear win — replacing 1,928 lines of generated catalog with a compile-time import rewrite. The previous review rounds have addressed the critical issues well. The implementation is well-structured.

Key findings:

  1. Variable shadowing ([s, e] shadows outer MagicString s) — flagged in previous bonk review but still unfixed. Provided a suggestion to rename to [start, end].

  2. Silent self-hosting skip needs documentation — when import and constructor call share a line, self-hosting is silently skipped (font loads from CDN). The overlap check is correct, but the user-facing consequence should be documented in a comment.

  3. utilityExports sync hazard — the hardcoded set must stay in sync with font-google.ts exports. Suggested adding a test to catch drift automatically.

  4. Minor regex note^ with m flag can match inside template literals. Acceptable tradeoff in practice.

github run

@yunus25jmi1
Copy link
Contributor Author

Addressed all 3 items from the latest bonk review:

  1. Fixed variable shadowing — Renamed [s, e] to [start, end] in the overlap-check callback to avoid shadowing the outer MagicString variable s (index.ts:3010).

  2. Documented self-hosting skip — Added a NOTE: comment explaining that fonts whose constructor calls overlap with the rewritten import region will still work but load from CDN instead of being self-hosted (index.ts:3005-3008).

  3. Added utilityExports sync test — New test (font-google.test.ts) that imports font-google.ts and verifies the actual non-default exports match the hardcoded utilityExports set in the plugin, catching any drift automatically.

All 58 font-google tests pass.

@james-elicx
Copy link
Collaborator

/bigbonk re-review the pr please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(font): replace generated font catalog with Vite import-rewriting transform

The core approach is a clear win — replacing ~1,900 lines of generated catalog with a compile-time import rewrite eliminates maintenance burden and makes any Google Font work automatically. The implementation is well-structured with good separation between import rewriting and build-only self-hosting. Previous review rounds have addressed the major issues (multiple imports, MagicString overlap detection, alias support, digit-containing font names, variable shadowing, sync test).

I have a few remaining observations, ranging from a potential correctness issue to minor suggestions.

Summary

  1. path.sep vs / cross-platform inconsistency — The shim guard at line 2900 uses path.sep while the existing _shimsDir constant at line 370 uses a hardcoded /. These should be consistent, and ideally the new code should reuse _shimsDir rather than recomputing the path.

  2. type specifiers are always re-emitted even if no font imports exist — If a user has import { type FontConfig } from 'next/font/google' alongside actual font names, the type specifiers are correctly preserved. But the current logic also adds createFontLoader as __vinext_clf even when all specifiers ended up in typeSpecifiers/utilityImports. The early fontImports.length === 0 bail-out handles this correctly — good.

  3. Redundant path.resolve computation — The path.resolve(__dirname, 'shims') is computed on every transform call. This could be hoisted to configResolved or reuse the existing module-level _shimsDir.

  4. The .d.ts still exports all ~1,900 font names — This is correct for IDE autocomplete, but it means generate-google-fonts.js still needs to be run periodically. Worth a brief note in the PR description that the generated .d.ts is retained intentionally for DX.

  5. Good test coverage — The alias test, digit-font test, multiple-import test, and utilityExports sync test are all solid additions.

const fontCallRe = /\b([A-Z][A-Za-z]*(?:_[A-Z][A-Za-z]*)*)\s*\(\s*(\{[^}]*\})\s*\)/g;
// Skip our own shim files — they contain 'next/font/google' in comments
// and re-export statements that should not be rewritten.
if (id.startsWith(path.resolve(__dirname, "shims") + path.sep)) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: There's already a module-level constant _shimsDir at line 370 that computes the same path:

const _shimsDir = path.resolve(__dirname, "shims") + "/";

This line recomputes it on every transform call and uses path.sep instead of the hardcoded / that _shimsDir uses. On Windows, path.sep is \ while the existing constant uses /, so the behavior would differ.

Consider reusing _shimsDir for consistency and to avoid redundant computation:

Suggested change
if (id.startsWith(path.resolve(__dirname, "shims") + path.sep)) return null;
if (id.startsWith(_shimsDir)) return null;

// The ^ anchor with 'm' flag ensures we only match real import statements
// at the start of a line, not occurrences inside comments or strings.
// Uses the 'gm' flags to handle multiple import statements in one file.
const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ^ anchor with m flag means this will match import at the start of any line, including inside multiline template literals or multiline comments. This has been noted in previous reviews as an acceptable tradeoff, and I agree — an AST-based approach would be overkill here.

However, there's one practical edge case worth considering: a multiline block comment like:

/*
import { Inter } from 'next/font/google'
*/

This would be matched and rewritten, breaking the comment. Consider whether adding a simple pre-check (e.g., stripping block comments first, or verifying the match isn't inside /* */) is worth the complexity. It's a judgment call — this pattern is unlikely but not impossible (e.g., someone commenting out an import during debugging).

// Merge specifiers from all matched imports
const rawSpecifiers: string[] = [];
for (const m of importMatches) {
rawSpecifiers.push(...m[1].split(",").map((s) => s.trim()).filter(Boolean));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The lambda parameter (s) shadows the outer MagicString variable s declared at line 2947. While not a bug (since s here is just the string from split), it's a readability hazard in a function that already had a variable shadowing issue flagged in previous reviews.

Consider renaming to avoid confusion:

Suggested change
rawSpecifiers.push(...m[1].split(",").map((s) => s.trim()).filter(Boolean));
rawSpecifiers.push(...m[1].split(",").map((spec) => spec.trim()).filter(Boolean));

const end = start + m[0].length;
overwrittenRanges.push([start, end]);
if (i === 0) {
s.overwrite(start, end, newImport + "\n" + fontDecls);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the first import is replaced with newImport + "\n" + fontDecls, the replacement text could be significantly longer or shorter than the original import line. This is fine for MagicString (it handles variable-length replacements), but it means the source map will map the entire block of const declarations back to a single original import line.

This is acceptable for a Vite transform — just noting it for awareness since debugging through source maps in this region may show unexpected line numbers.

Comment on lines +2919 to +2922
const utilityExports = new Set([
"buildGoogleFontsUrl", "getSSRFontLinks", "getSSRFontStyles",
"getSSRFontPreloads", "createFontLoader",
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that this is validated by the test at font-google.test.ts:672. One thing to watch: the getSSRFontPreloads export is in this set but isn't exercised in many of the plugin tests. If it were ever removed from font-google.ts, the sync test would catch it, but it's worth making sure the test at line 672 is robust (which it is — it compares sorted arrays).

// Convert export-style names to proper font family names:
// - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono"
// - PascalCase to spaces: "RobotoMono" -> "Roboto Mono"
const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good improvement — the Proxy now handles both Roboto_Mono (underscore) and RobotoMono (PascalCase) naming conventions. This makes the fallback Proxy consistent with how the Vite plugin derives family names.

Note that the order matters here: replace(/_/g, " ") runs first, then replace(/([a-z])([A-Z])/g, ...). For a name like Roboto_Mono, the underscore replacement produces "Roboto Mono" and the PascalCase replacement is a no-op (since there's a space between o and M). For RobotoMono, the underscore replacement is a no-op and the PascalCase replacement produces "Roboto Mono". This is correct.

// ── utilityExports sync validation ────────────────────────────

describe("utilityExports sync", () => {
it("hardcoded utilityExports matches actual non-font exports from font-google.ts", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a valuable test — it catches drift between the hardcoded utilityExports set in the plugin and the actual exports from font-google.ts. Well done adding this after the review feedback.

One edge case: if someone adds a non-function export to font-google.ts (e.g., a constant or type re-export), this test would catch it since it checks all non-default keys. That's the right behavior.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted successfully. Here's a summary of what I found:

Overall assessment: The PR is in good shape. Replacing ~1,900 lines of generated font catalog with a compile-time Vite import rewrite is a clear maintenance and bundle-size win. The author has addressed all critical issues from previous review rounds.

Key findings:

  1. Cross-platform inconsistency (actionable) — Line 2900 uses path.resolve(__dirname, "shims") + path.sep but there's already a module-level _shimsDir constant at line 370 using / as the separator. Suggested reusing _shimsDir for consistency and to avoid redundant computation.

  2. Variable shadowing in .map() callback — The lambda parameter (s) at line 2913 shadows the outer MagicString variable s. Minor readability hazard; suggested renaming to spec.

  3. Commented-out imports could be matched — The ^ anchor with m flag could match import statements inside block comments (/* ... */). Noted as an acceptable tradeoff but worth awareness.

  4. Positive notes — Good test coverage for aliases, digit-containing fonts, multiple imports, and the utilityExports sync test. The Proxy fallback improvement in font-google-base.ts correctly handles both underscore and PascalCase naming conventions.

github run

Address bonk review:
- Reuse module-level _shimsDir constant instead of recomputing
  path.resolve(__dirname, 'shims') + path.sep on every transform call.
  Also fixes cross-platform inconsistency (path.sep vs hardcoded /).
- Rename (s) to (spec) in .map() callback to avoid shadowing the
  outer MagicString variable s.
@yunus25jmi1
Copy link
Contributor Author

Addressed the latest bonk review items:

  • *Reuse _shimsDir* — replaced \path.resolve(__dirname, 'shims') + path.sep\ with the existing module-level _shimsDir\ constant. Avoids redundant computation on every transform call and fixes the cross-platform inconsistency (\path.sep\ vs hardcoded /).
  • *Renamed (s)\ to (spec)* in the .map()\ callback to avoid shadowing the outer MagicString variable \s.

All 58 font-google tests pass. Typecheck clean.

@james-elicx
Copy link
Collaborator

/bigbonk re-review please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The core idea is a clear win — replacing ~1,900 lines of generated catalog with a compile-time import rewrite is a significant maintenance and bundle-size improvement. The implementation is well-structured with clean separation between import rewriting (dev+build) and self-hosting (build-only).

The author has iterated well through previous review rounds, addressing the critical issues (multiple imports via gm flags, MagicString overlap detection, nitro-compat removal, alias support, variable shadowing fix, _shimsDir reuse, utilityExports sync test).

I found one functional concern with the self-hosting regex and a few minor items below.

// Match font constructor calls: Inter({ weight: ..., subsets: ... })
// We look for PascalCase/Name_Name/Name_2 identifiers followed by ({...}).
// The segment after _ can start with any alphanumeric (e.g. Baloo_2, M_PLUS_1p).
const fontCallRe = /\b([A-Z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*)\s*\(\s*(\{[^}]*\})\s*\)/g;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The options-matching group \{[^}]*\} cannot match nested objects because [^}] stops at the first }. For example:

const inter = Inter({ weight: '400', axes: { wght: 400 } });

would not be matched by fontCallRe, so self-hosting would silently skip it (font falls back to CDN).

I realize this is a pre-existing limitation (the old code had the same regex), but since this PR is the natural place to improve it — and parseStaticObjectLiteral already handles nested objects correctly (there's even a test for it at line 611) — it's worth noting.

A simple brace-counting approach would fix this:

// Instead of \{[^}]*\}, match balanced braces
function extractBalancedBraces(code: string, openIndex: number): string | null {
  let depth = 0;
  for (let i = openIndex; i < code.length; i++) {
    if (code[i] === '{') depth++;
    else if (code[i] === '}') { depth--; if (depth === 0) return code.slice(openIndex, i + 1); }
  }
  return null;
}

Not blocking — just calling it out as a follow-up opportunity.

importMatch[1].split(",").map((s) => s.trim()).filter(Boolean),
);
// Nothing to transform if no font-name imports
if (fontImports.length === 0) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a file has import { buildGoogleFontsUrl } from 'next/font/google' (only utility imports, no font names), the transform returns null and the import passes through unchanged. This is correct because font-google.ts already exports buildGoogleFontsUrl and the import will resolve normally.

However, if a file has import { type FontLoader } from 'next/font/google' (only type imports), fontImports is empty and we also return null. This is also correct since TypeScript will have already erased type-only imports before the Vite transform runs.

Just confirming I traced through both paths and they're handled correctly — nice.

const newSpecifiers = [
...typeSpecifiers,
...utilityImports,
"createFontLoader as __vinext_clf",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: createFontLoader as __vinext_clf is always added to newSpecifiers, even when it's already present in utilityImports (because the user explicitly imported createFontLoader). This would produce:

import { createFontLoader, createFontLoader as __vinext_clf } from 'next/font/google';

This is valid JS (duplicate specifiers are allowed), but it's slightly wasteful. Consider skipping the __vinext_clf alias when createFontLoader is already in utilityImports:

Suggested change
"createFontLoader as __vinext_clf",
"createFontLoader as __vinext_clf",

Alternatively, if createFontLoader is in utilityImports, you could remove it from there and only add the aliased version. Not blocking — the current output works.

const fontCallRe = /\b([A-Z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*)\s*\(\s*(\{[^}]*\})\s*\)/g;

let callMatch;
while ((callMatch = fontCallRe.exec(code)) !== null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The self-hosting regex runs on the original code while the MagicString s has already had import regions overwritten. This is correct because MagicString tracks overwrites by original-string indices, and the overlap check on line 3011 prevents double-overwrites. The s.overwrite(matchStart, matchEnd, ...) on line 3083 uses original-code indices, which is what MagicString expects.

This is a subtle but important correctness property — worth a one-line comment noting that fontCallRe intentionally runs on the original code (not s.toString()) because MagicString uses original indices.

// The ^ anchor with 'm' flag ensures we only match real import statements
// at the start of a line, not occurrences inside comments or strings.
// Uses the 'gm' flags to handle multiple import statements in one file.
const importRe = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]next\/font\/google['"]\s*;?/gm;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [ \t]* prefix allows optional indentation, but it also means the regex won't match an import preceded by, say, a semicolon on the same line:

;import { Inter } from 'next/font/google'

This is fine — that's not valid formatting. But there's one real-world edge case: a file with a BOM (byte order mark) at the very beginning. The BOM (\uFEFF) would appear before the first import and prevent the ^ anchor from matching on line 1 (since ^ with m flag matches start-of-line, and the BOM is a character before import).

This is extremely unlikely in practice (modern toolchains strip BOMs), so not blocking. Just noting for completeness.

// ── utilityExports sync validation ────────────────────────────

describe("utilityExports sync", () => {
it("hardcoded utilityExports matches actual non-font exports from font-google.ts", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sync test is a great addition — it catches drift between the hardcoded utilityExports in the plugin and the actual exports from font-google.ts.

One subtle thing: the test imports from font-google.js and checks Object.keys(mod).filter(k => k !== 'default'). Since font-google.ts re-exports from font-google-base.ts, the keys here reflect the runtime exports. If someone added a type-only export to font-google.ts, it wouldn't show up in Object.keys(mod) and wouldn't trigger a test failure — but it also wouldn't need to be in utilityExports since the plugin only deals with runtime values. So this is correct.

// Convert export-style names to proper font family names:
// - Underscores to spaces: "Roboto_Mono" -> "Roboto Mono"
// - PascalCase to spaces: "RobotoMono" -> "Roboto Mono"
const family = prop.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Proxy now handles both underscore (Roboto_Mono) and PascalCase (RobotoMono) naming. The order of replacements is correct: underscore-to-space first, then PascalCase-to-space. For Roboto_Mono, the first replacement produces "Roboto Mono" and the second is a no-op.

This makes the Proxy a correct fallback for any code path that bypasses the Vite plugin (e.g., direct mod.default.SomeFontName access at runtime).

const end = start + m[0].length;
overwrittenRanges.push([start, end]);
if (i === 0) {
s.overwrite(start, end, newImport + "\n" + fontDecls);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the first import is replaced with newImport + "\n" + fontDecls, the replacement text could be significantly longer than the original import line. This means the source map will map the entire block of const declarations back to a single original import line.

This is an acceptable tradeoff for a Vite transform, but it means that if a user sets a breakpoint on one of the generated const declarations, the debugger may behave unexpectedly. Worth knowing but not worth fixing — the hires: "boundary" option on line 3089 already provides the best mapping MagicString can offer for variable-length replacements.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 8, 2026

Review submitted successfully. Here's a summary of what I found:

Overall assessment: The PR is in good shape. Replacing ~1,900 lines of generated font catalog with a compile-time Vite import rewrite is a clear maintenance and bundle-size win. The author has addressed all critical issues from previous review rounds well.

Key findings:

  1. Nested object options in self-hosting regex (non-blocking) — The \{[^}]*\} pattern in fontCallRe can't match nested objects like Inter({ axes: { wght: 400 } }), causing self-hosting to silently skip such fonts. Pre-existing limitation but worth noting as a follow-up since parseStaticObjectLiteral already handles nested objects.

  2. Duplicate createFontLoader specifier (minor) — If a user explicitly imports createFontLoader, the transform adds it twice in the rewritten import (once as the user's import, once as __vinext_clf alias). Valid JS but slightly wasteful.

  3. Self-hosting regex intentionally on original code — Confirmed this is correct since MagicString tracks by original indices, but suggested adding a comment to make this non-obvious correctness property explicit.

  4. Positive notes — Good _shimsDir reuse, well-handled edge cases for type imports and utility-only imports, correct Proxy fallback for both underscore and PascalCase naming, and the utilityExports sync test is a valuable addition.

github run

@yunus25jmi1
Copy link
Contributor Author

Addressed the latest bonk review items:

  • Deduplicate createFontLoader specifier -- when the user explicitly imports createFontLoader, it is now filtered from utilityImports before adding the __vinext_clf alias, preventing duplicate specifiers in the rewritten import. Added a test to cover this case.
  • Added comment about fontCallRe on original code -- documented that the self-hosting regex intentionally runs on the original code string (not s.toString()) because MagicString tracks overwrites by original indices.

All 59 font-google tests pass. Typecheck clean.

@yunus25jmi1
Copy link
Contributor Author

@james-elicx Review the PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore Vite transform for next/font/google imports (eliminate generated font catalog)

2 participants